########################################################################
#  Searx-qt - Lightweight desktop application for SearX.
#  Copyright (C) 2020  CYBERDEViL
#
#  This file is part of Searx-qt.
#
#  Searx-qt is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  Searx-qt is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################

from PyQt5.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLineEdit,
    QTextBrowser,
    QCheckBox,
    QLabel,
    QComboBox,
    QFrame,
    QMenu,
    QWidgetAction,
    QSpacerItem,
    QSizePolicy
)

from PyQt5.QtCore import pyqtSignal, QThread, Qt, QVariant

from searxqt.models.search import SearchStatus

from searxqt.widgets.buttons import Button, CheckboxOptionsButton


class Thread(QThread):
    finished = pyqtSignal()

    def __init__(self, func, args=[], kwargs={}, parent=None):
        QThread.__init__(self, parent=parent)
        self._func = func
        self._args = args
        self._kwargs = kwargs

        self._result = None

    def result(self): return self._result

    def run(self):
        self._result = self._func(*self._args, **self._kwargs)
        self.finished.emit()


class SearchNavigation(QWidget):
    requestPage = pyqtSignal(int)  # pageno

    def __init__(self, parent=None):
        QWidget.__init__(self, parent=parent)
        layout = QHBoxLayout(self)

        self.prevPageButton = Button("<<", self)
        self.pageNoLabel = QLabel("1", self)
        self.nextPageButton = Button(">>", self)

        layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
        layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
        layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)

        self.prevPageButton.setEnabled(False)

        self.nextPageButton.clicked.connect(self._nextPage)
        self.prevPageButton.clicked.connect(self._prevPage)

        self.reset()

    def _updateLabel(self):
        self.pageNoLabel.setText(str(self._pageno))

    def _nextPage(self):
        self._pageno += 1
        if self._pageno > 1 and not self.prevPageButton.isEnabled():
            self.prevPageButton.setEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def _prevPage(self):
        self._pageno -= 1
        if self._pageno == 1:
            self.prevPageButton.setEnabled(False)
        self.setNextEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def reset(self):
        self._pageno = 1
        self.prevPageButton.setEnabled(False)
        self._updateLabel()

    def setNextEnabled(self, state):
        self.nextPageButton.setEnabled(state)


class SearchEngines(CheckboxOptionsButton):
    def __init__(self, searchModel, instancesModel, parent=None):
        """
        @param searchModel: needed for getting and setting current
                            enabled/disabled engines.
        @type searchModel: SearchModel

        @param instancesModel: needed for listing current available
                               engines and update it's current filter
                               to filter out instances without atleast
                               one of the required engine(s).
        @type instancesModel: InstanceModelFilter
        """
        self._instancesModel = instancesModel
        self._searchModel = searchModel
        CheckboxOptionsButton.__init__(
            self,
            labelName="Engines",
            parent=parent
        )

    """ Below are re-implementations.
    """
    def getCheckedOptionNames(self):
        """ Should return a list with checked option names.

        @return: should return a list with strings.
        @rtype: list
        """
        return self._searchModel.engines

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        tmp = []
        for url, instance in self._instancesModel.items():
            for engine in instance.engines:
                if engine.enabled and engine.name not in tmp:
                    state = engine.name in self.getCheckedOptionNames()
                    list_.append((engine.name, engine.name, state))
                    tmp.append(engine.name)

        return sorted(list_)

    def optionToggled(self, key, state):
        if state:
            self._searchModel.engines.append(key)
        else:
            self._searchModel.engines.remove(key)

        self._instancesModel.updateKwargs(
            {'engines': self.getCheckedOptionNames()}
        )


class SearchCategories(CheckboxOptionsButton):
    def __init__(self, searchModel, parent=None):
        self._searchModel = searchModel
        CheckboxOptionsButton.__init__(
            self,
            labelName="Categories",
            parent=parent
        )

    """ Below are re-implementations.
    """
    def getCheckedOptionNames(self):
        """ Should return a list with checked option names.

        @return: should return a list with strings.
        @rtype: list
        """
        return(
            [value[0] for value in
                self._searchModel.categories.types.values()]
        )

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        for key, t in self._searchModel.categories.types.items():
            state = self._searchModel.categories.get(key)
            list_.append((key, t[0], state))

        return list_

    def optionToggled(self, key, state):
        self._searchModel.categories.set(key, state)


class SearchPeriod(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model

        self.setMinimumContentsLength(2)

        for period in model.Periods:
            self.addItem(model.Periods[period], QVariant(period))

        self.currentIndexChanged.connect(self.__indexChanged)

    def __indexChanged(self, index):
        self._model.timeRange = self.currentData()


class SearchLanguage(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model

        self.setMinimumContentsLength(2)

        for lang in model.Languages:
            self.addItem(model.Languages[lang], QVariant(lang))

        self.currentIndexChanged.connect(self.__indexChanged)

    def __indexChanged(self, index):
        self._model.lang = self.currentData()


class SearchOptionsContainer(QFrame):
    """ Custom QFrame to be able to show or hide certain widgets.
    """
    def __init__(self, searchModel, instancesModel, parent=None):
        QFrame.__init__(self, parent=parent)

        layout = QHBoxLayout(self)

        self._widgets = {
            'categories': SearchCategories(searchModel, self),
            'engines': SearchEngines(searchModel, instancesModel, self),
            'period': SearchPeriod(searchModel, self),
            'lang': SearchLanguage(searchModel, self)
        }

        for widget in self._widgets.values():
            layout.addWidget(widget, 0, Qt.AlignTop)

        # Keep widgets left aligned.
        spacer = QSpacerItem(
            40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum
        )
        layout.addItem(spacer)

    def saveSettings(self):
        data = {}
        for key, widget in self._widgets.items():
            data.update(
                {
                    '{0}Visible'.format(key): not widget.isHidden()
                }
            )
        return data

    def loadSettings(self, data):
        for key, widget in self._widgets.items():
            if data.get('{0}Visible'.format(key), True):
                widget.show()
            else:
                widget.hide()

    def __checkBoxStateChanged(self, key, state):
        if state:
            self._widgets[key].show()
        else:
            self._widgets[key].hide()

    """ QFrame re-implementations
    """
    def contextMenuEvent(self, event):
        menu = QMenu(self)

        menu.addSection("Show / Hide")

        for key, widget in self._widgets.items():
            action = QWidgetAction(menu)
            checkbox = QCheckBox(key, menu)
            checkbox.setTristate(False)
            checkbox.setChecked(not widget.isHidden())
            action.setDefaultWidget(checkbox)

            checkbox.stateChanged.connect(
                lambda state, key=key:
                    self.__checkBoxStateChanged(key, state)
            )

            menu.addAction(action)

        menu.exec_(self.mapToGlobal(event.pos()))


class SearchContainer(QWidget):
    def __init__(self, searchModel, instancesModel,
                 instanceSelecter, parent=None):
        """
        @type searchModel: models.search.SearchModel
        @type instancesModel: models.instances.InstancesModelFilter
        @type instanceSelecter: models.instances.instanceSelecterModel
        """

        QWidget.__init__(self, parent=parent)
        layout = QVBoxLayout(self)

        self._model = searchModel
        self._instancesModel = instancesModel
        self._instanceSelecter = instanceSelecter
        self._searchThread = None

        # Current fail count.
        self._searchFailCount = 0

        # Maximum other instances to try on fail.
        self._maxSearchFailCount = 10

        # Set `_useFallback` to True to try another instance when the
        # search failed somehow or set to False to try same instance or
        # for pagination which also should use the same instance.
        self._useFallback = True

        # `_fallbackActive` should be False when a fresh list of fallback
        # instances should be picked on failed search and should be True
        # when search(es) fail and `_fallbackInstancesQueue` is beeing
        # used.
        self._fallbackActive = False

        # Every first request that has `_useFallback` set to True will
        # use this list as a resource for next instance(s) to try until
        # it is out of instance url's. Also on the first request this
        # list will be cleared and filled again with `_maxSearchFailCount`
        # of random instances.
        self._fallbackInstancesQueue = []

        # Set to True to break out of the fallback loop.
        # This is used for the Stop action.
        self._breakFallback = False

        searchLayout = QHBoxLayout()
        layout.addLayout(searchLayout)

        # -- Start search bar
        self.queryEdit = QLineEdit(self)
        self.queryEdit.setPlaceholderText("Search for ..")
        searchLayout.addWidget(self.queryEdit)

        self.searchButton = Button("Searx", self)
        self.searchButton.setToolTip("Preform search.")
        searchLayout.addWidget(self.searchButton)

        self.reloadButton = Button("♻", self)
        self.reloadButton.setToolTip("Reload")
        searchLayout.addWidget(self.reloadButton)

        self.randomButton = Button("⤳", self)
        self.randomButton.setToolTip("Search with random instance.\n"
                                     "(Obsolete when 'Random Every is "
                                     "checked')")
        searchLayout.addWidget(self.randomButton)

        rightLayout = QVBoxLayout()
        rightLayout.setSpacing(0)
        searchLayout.addLayout(rightLayout)

        self._fallbackCheck = QCheckBox("Fallback", self)
        self._fallbackCheck.setToolTip("Try random other instance on fail.")
        rightLayout.addWidget(self._fallbackCheck)

        self._randomCheckEvery = QCheckBox("Random every", self)
        self._randomCheckEvery.setToolTip("Pick a random instance for "
                                          "every request.")
        rightLayout.addWidget(self._randomCheckEvery)
        # -- End search bar

        # -- Start search options toolbar
        self._optionsContainer = SearchOptionsContainer(
            searchModel, instancesModel, self
        )
        layout.addWidget(self._optionsContainer)
        # -- End search options toolbar

        self.resultsContainer = QTextBrowser(self)
        self.resultsContainer.setOpenExternalLinks(True)
        self.resultsContainer.setLineWrapMode(1)
        layout.addWidget(self.resultsContainer)

        self.navBar = SearchNavigation(self)
        self.navBar.setEnabled(False)
        layout.addWidget(self.navBar)

        self.queryEdit.textChanged.connect(self.__queryChanged)
        self._model.statusChanged.connect(self.__searchStatusChanged)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._instanceSelecter.instanceChanged.connect(
                                self.__instanceChanged)

        self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
        self.searchButton.clicked.connect(self.__searchButtonClicked)
        self.reloadButton.clicked.connect(self.__reloadButtonClicked)
        self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
        self.navBar.requestPage.connect(self.__navBarRequest)

        self.__queryChanged("")

    def __searchButtonClicked(self, checked=0):
        # Set to use fallback
        self._useFallback = self._model.useFallback

        if (self._model.randomEvery or
                (self._useFallback and
                    not self._instanceSelecter.currentUrl)):
            self._instanceSelecter.randomInstance()

        self._resetPagination()

        self._newSearch(self._instanceSelecter.currentUrl)

    def __stopButtonClicked(self):
        self._breakFallback = True
        self.searchButton.setEnabled(False)

    def __reloadButtonClicked(self):
        self._useFallback = False
        self._newSearch(self._instanceSelecter.currentUrl)

    def __randomSearchButtonClicked(self):
        self._useFallback = self._model.useFallback
        self._instanceSelecter.randomInstance()
        self._newSearch(self._instanceSelecter.currentUrl)

    def __navBarRequest(self, pageNo):
        self._useFallback = False
        self._model.pageno = pageNo
        self._newSearch(self._instanceSelecter.currentUrl)

    def _resetPagination(self):
        self.navBar.reset()
        self._model.pageno = 1

    def __instanceChanged(self):
        self._resetPagination()

    def __searchOptionsChanged(self):
        """ From the model (on load settings)
        """
        self._randomCheckEvery.stateChanged.disconnect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.disconnect(
                                self.__useFallbackChanged)

        self._randomCheckEvery.setChecked(self._model.randomEvery)
        self._fallbackCheck.setChecked(self._model.useFallback)

        self.__handleRandomButtonState()

        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)

    def __handleRandomButtonState(self):
        """ Hides or shows the 'Random search button'.
        We don't need the button when the model it's randomEvery is True.
        """
        if self._model.randomEvery:
            self.randomButton.hide()
        else:
            self.randomButton.show()

    def __randomEveryRequestChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.randomEvery = bool(state)
        self.__handleRandomButtonState()
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __useFallbackChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.useFallback = bool(state)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __queryChanged(self, q):
        if self._model.status() == SearchStatus.Busy:
            return

        if q:
            self.searchButton.setEnabled(True)
            self.reloadButton.setEnabled(True)
            self.randomButton.setEnabled(True)
        else:
            self.searchButton.setEnabled(False)
            self.reloadButton.setEnabled(False)
            self.randomButton.setEnabled(False)

    def _setOptionsState(self, state=True):
        if self._useFallback:
            if state:
                # Search stopped
                self.searchButton.setText("Search")
                self.searchButton.clicked.disconnect(
                    self.__stopButtonClicked
                )
                self.searchButton.clicked.connect(self.__searchButtonClicked)
                self.searchButton.setEnabled(True)
            else:
                # Searching
                self.searchButton.setText("Stop")
                self.searchButton.clicked.disconnect(
                    self.__searchButtonClicked
                )
                self.searchButton.clicked.connect(self.__stopButtonClicked)
        else:
            self.searchButton.setEnabled(state)

        self.reloadButton.setEnabled(state)
        self.randomButton.setEnabled(state)
        self._randomCheckEvery.setEnabled(state)
        self._fallbackCheck.setEnabled(state)
        self.queryEdit.setEnabled(state)
        self._optionsContainer.setEnabled(state)

    def __searchStatusChanged(self, status):
        if status == SearchStatus.Busy:
            self._setOptionsState(False)

        elif status == SearchStatus.Done:
            self._setOptionsState()

    @property
    def query(self): return self.queryEdit.text()

    def _newSearch(self, url, query=''):
        self.resultsContainer.clear()

        self._search(url, query)

    def _search(self, url, query=''):
        if self._searchThread:
            return

        if not query:
            query = self.query

        if not query:
            self.resultsContainer.setHtml("Please enter a search query.")
            return

        if not url:
            self.resultsContainer.setHtml("Please select a instance first.")
            return

        self.navBar.setEnabled(False)

        self._model.url = url
        self._model.query = query

        kwargs = {
            'requestKwargs': {
                'data': {
                    'q': query,
                    'format': 'json'
                },
            }
        }
        kwargs['requestKwargs'].update(self._model.requestSettings.data)

        self._searchThread = Thread(self._model.search, kwargs=kwargs,
                                    parent=self)
        self._searchThread.finished.connect(self._searchFinished)
        self._searchThread.start()

    def _searchFailed(self, result):
        if self._useFallback:  # Re-try another instance
            if self._breakFallback:  # Stop button pressed
                self._breakFallback = False
                return

            if not self._fallbackActive:
                # Get new list with instances to try same request.
                self._fallbackActive = True
                self._fallbackInstancesQueue.clear()
                self._fallbackInstancesQueue = (
                        self._instanceSelecter.getRandomInstances(
                                    amount=self._maxSearchFailCount))

            if not self._fallbackInstancesQueue:
                self.resultsContainer.setHtml(
                    "Max fail count reached! ({0})".format(
                        self._maxSearchFailCount))
                self._fallbackActive = False
                return

            # Set next instance url to try.
            self._instanceSelecter.currentUrl = (
                    self._fallbackInstancesQueue.pop(0))

            self._search(self._instanceSelecter.currentUrl)
            return

        if self._model.pageno > 1:
            self.navBar.setEnabled(True)
            self.navBar.setNextEnabled(False)

        self.resultsContainer.setHtml("Search failed: {} {}"
                                      .format(result.errorType(),
                                              result.error()))

    def _searchFinished(self):
        result = self._searchThread.result()
        self._clearSearchThread()

        if not bool(result):  # Failed
            self._searchFailed(result)
            return

        self._fallbackActive = False

        # Create HTML from results
        elemStr = ""
        for item in result.json().get('results', {}):
            elemStr += self.createResultElement(item)

        self.resultsContainer.setHtml(self.compileHtml(elemStr))
        self.navBar.setEnabled(True)
        self.navBar.setNextEnabled(True)

    def _clearSearchThread(self):
        self._searchThread.finished.disconnect(self._searchFinished)
        self._searchThread.deleteLater()
        self._searchThread = None

    def createResultElement(self, data):
        """result['title']
        result['content']
        result['url']
        result['engine']
        """

        elem = """<div class="result" id="result-{id}">
<h4 class="result-title"><i>{engine}: </i><a href="{url}">{title}</a></h4>
<p class="result-description">{content}</p>
<p class="result-url">{url}</p>
</div>""".format(id="TODO-id", title=data.get('title', ''),
                 url=data.get('url', ''), content=data.get('content', ''),
                 engine=data.get('engine', '?'))
        return elem

    def compileHtml(self, elemStr=""):
        return """<html>
<head>
        <title>Yeah</title>
</head>
<body>
    {0}
</body>
</html>""".format(elemStr)

    def saveSettings(self):
        return {
            'searchOptions': self._optionsContainer.saveSettings()
        }

    def loadSettings(self, data):
        self._optionsContainer.loadSettings(
            data.get('searchOptions', {})
        )
